@fabrika/builder 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/BUILD-CONTRACT.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +100 -0
  4. package/cli/src/bin.ts +66 -0
  5. package/cli/src/build-status.ts +109 -0
  6. package/cli/src/catalog.ts +42 -0
  7. package/cli/src/commands/advance.ts +73 -0
  8. package/cli/src/commands/build.ts +215 -0
  9. package/cli/src/commands/ensure-daemon.ts +25 -0
  10. package/cli/src/commands/gate.ts +165 -0
  11. package/cli/src/commands/induce.ts +61 -0
  12. package/cli/src/commands/init.ts +91 -0
  13. package/cli/src/commands/next.ts +31 -0
  14. package/cli/src/config.ts +47 -0
  15. package/cli/src/context.ts +28 -0
  16. package/cli/src/daemon-client.ts +280 -0
  17. package/cli/src/daemon-lifecycle.ts +171 -0
  18. package/cli/src/derive.ts +109 -0
  19. package/cli/src/index.ts +38 -0
  20. package/cli/src/induce.ts +151 -0
  21. package/cli/src/inject-skills.ts +313 -0
  22. package/cli/src/issues.ts +75 -0
  23. package/cli/src/layout.ts +124 -0
  24. package/cli/src/packet.ts +108 -0
  25. package/cli/src/phase-state.ts +19 -0
  26. package/cli/src/phases.ts +131 -0
  27. package/cli/src/skeletons.ts +104 -0
  28. package/daemon/src/bin.ts +70 -0
  29. package/daemon/src/build-stream.ts +38 -0
  30. package/daemon/src/handlers.ts +472 -0
  31. package/daemon/src/index.ts +123 -0
  32. package/daemon/src/paths.ts +30 -0
  33. package/daemon/src/phases.ts +221 -0
  34. package/daemon/src/static.ts +72 -0
  35. package/decisions/README.md +133 -0
  36. package/decisions/patterns/app-deploy.md +55 -0
  37. package/decisions/patterns/auth-password-kv.md +45 -0
  38. package/decisions/patterns/count-store.md +44 -0
  39. package/decisions/patterns/di-container.md +47 -0
  40. package/decisions/patterns/feature-architecture.md +48 -0
  41. package/decisions/patterns/lib-publish.md +46 -0
  42. package/decisions/patterns/monorepo.md +47 -0
  43. package/decisions/patterns/named-seeds.md +45 -0
  44. package/decisions/patterns/observability.md +45 -0
  45. package/decisions/patterns/single-worker-spa.md +74 -0
  46. package/decisions/patterns/structured-logging.md +49 -0
  47. package/decisions/patterns/typed-worker-rpc.md +87 -0
  48. package/decisions/patterns/vozka-app-deploy.md +74 -0
  49. package/decisions/patterns/write-split.md +47 -0
  50. package/decisions/technologies/analytics-engine.md +44 -0
  51. package/decisions/technologies/bindx.md +55 -0
  52. package/decisions/technologies/bun.md +51 -0
  53. package/decisions/technologies/buzola.md +80 -0
  54. package/decisions/technologies/cloudflare-access.md +50 -0
  55. package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
  56. package/decisions/technologies/cloudflare.md +50 -0
  57. package/decisions/technologies/contember.md +65 -0
  58. package/decisions/technologies/cron-triggers.md +46 -0
  59. package/decisions/technologies/d1.md +51 -0
  60. package/decisions/technologies/durable-objects.md +44 -0
  61. package/decisions/technologies/kv.md +42 -0
  62. package/decisions/technologies/lopata.md +55 -0
  63. package/decisions/technologies/nuasite.md +50 -0
  64. package/decisions/technologies/oblaka.md +48 -0
  65. package/decisions/technologies/opice.md +40 -0
  66. package/decisions/technologies/pletivo.md +53 -0
  67. package/decisions/technologies/postgres.md +47 -0
  68. package/decisions/technologies/preact.md +44 -0
  69. package/decisions/technologies/propustka.md +137 -0
  70. package/decisions/technologies/queue.md +55 -0
  71. package/decisions/technologies/r2.md +41 -0
  72. package/decisions/technologies/react-router.md +47 -0
  73. package/decisions/technologies/react.md +37 -0
  74. package/decisions/technologies/roj.md +51 -0
  75. package/decisions/technologies/sentry.md +49 -0
  76. package/decisions/technologies/tailwind.md +45 -0
  77. package/decisions/technologies/vite.md +47 -0
  78. package/decisions/technologies/vozka.md +122 -0
  79. package/decisions/technologies/workers-ai.md +59 -0
  80. package/decisions/technologies/wrangler.md +41 -0
  81. package/decisions/tooling/deptective.md +45 -0
  82. package/decisions/tooling/edvabe.md +45 -0
  83. package/index.ts +2 -0
  84. package/package.json +51 -0
  85. package/skill/SKILL.md +76 -0
  86. package/skill/agents/build-orchestrator.md +170 -0
  87. package/skill/agents/flows-author.md +65 -0
  88. package/skill/agents/implementer.md +37 -0
  89. package/skill/agents/openai.yaml +4 -0
  90. package/skill/agents/opice-skeleton-author.md +68 -0
  91. package/skill/gates/brief.jsx +20 -0
  92. package/skill/gates/build.jsx +20 -0
  93. package/skill/gates/design.jsx +22 -0
  94. package/skill/gates/flows.jsx +14 -0
  95. package/skill/gates/review-grid.jsx +32 -0
  96. package/skill/gates/tech.jsx +17 -0
  97. package/skill/gates/vision.jsx +27 -0
  98. package/stacks/README.md +44 -0
  99. package/stacks/cf-fat.md +64 -0
  100. package/stacks/cf-internal-app.md +125 -0
  101. package/stacks/cf-lightweight.md +54 -0
  102. package/stacks/contember-app.md +53 -0
  103. package/stacks/content-site.md +45 -0
  104. package/ui/dist/assets/annotations-runtime.js +1 -0
  105. package/ui/dist/assets/client.css +2 -0
  106. package/ui/dist/assets/client.fhjppkvr.js +214 -0
  107. package/ui/dist/assets/components.js +63 -0
  108. package/ui/dist/assets/preact-compat.js +1 -0
  109. package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
  110. package/ui/dist/assets/react-jsx-runtime.js +3 -0
  111. package/ui/dist/assets/runtime.js +1 -0
  112. package/ui/dist/index.html +36 -0
@@ -0,0 +1,41 @@
1
+ # Builder contract
2
+
3
+ ## Package boundary
4
+
5
+ `@fabrika/builder` owns one complete guided-build feature:
6
+
7
+ - deterministic phase and issue logic under `cli/`
8
+ - the local daemon under `daemon/`
9
+ - the packaged dashboard under `ui/dist/`
10
+ - installed agent support under `skill/`
11
+ - the public stack and technology catalog under `stacks/` and `decisions/`
12
+
13
+ `@fabrika/cli` is the only public `fabrika` executable. It routes arguments after `fabrika builder` to `runBuilderCli()`.
14
+
15
+ The builder CLI and dashboard must not import daemon implementation modules. They communicate through HTTP and WebSocket endpoints. The daemon is the only builder layer that imports `@fabrika/canvas-kernel/server`.
16
+
17
+ ## Phase model
18
+
19
+ The ordered phases are intake, vision, flows, technology, design, skeletons, issues, and build. A phase is complete when every required output exists and its review gate, when present, has submitted and consumed feedback.
20
+
21
+ The phase is always derived from application files and gate state. No mutable phase counter is authoritative.
22
+
23
+ ## Application state
24
+
25
+ `fabrika builder init` creates `.fabrika/config.json`, layered artifact directories, and `tests/browser/`. It installs agent support without overwriting user-edited generated files.
26
+
27
+ The stable session ID identifies dashboard revisions. Persistent daemon state defaults to `~/.fabrika` and can be moved with `FABRIKA_DATA_DIR`.
28
+
29
+ ## Gate protocol
30
+
31
+ 1. `gate open` sends one view or a directory of views to `POST /api/session/:id/plan`.
32
+ 2. The daemon compiles the selected canvas files and creates a revision.
33
+ 3. The dashboard submits feedback over `/ws/session/:id`.
34
+ 4. `gate wait` observes feedback without consuming it.
35
+ 5. `advance` validates outputs and consumes the latest feedback.
36
+
37
+ Only `advance` consumes feedback. This keeps retries and re-opened gates pending until the agent explicitly advances.
38
+
39
+ ## Release artifact
40
+
41
+ The release packer runs `release:prepare`, which builds the dashboard into `ui/dist/`. The npm package includes runtime source, dashboard assets, skills, decisions, and stack presets. Tests and development-only source are excluded.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contember Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @fabrika/builder
2
+
3
+ `@fabrika/builder` guides a coding agent from an initial application brief to a reviewed acceptance contract and an assisted implementation.
4
+
5
+ The builder runs eight deterministic phases:
6
+
7
+ 1. intake
8
+ 2. vision
9
+ 3. flows
10
+ 4. technology
11
+ 5. design
12
+ 6. opice test skeletons
13
+ 7. implementation issues
14
+ 8. build
15
+
16
+ The current phase is derived from files in `.fabrika/`; it is not stored as a mutable counter. Review gates use a local dashboard with persistent annotations and responses.
17
+
18
+ ## Usage
19
+
20
+ Install the public CLI and initialize the builder in an application repository:
21
+
22
+ ```bash
23
+ bun add --global @fabrika/cli
24
+ fabrika builder init
25
+ ```
26
+
27
+ Initialization installs the Fabrika skill for Claude Code and Codex, starts the local daemon, and opens the dashboard. The coding agent then drives the phase loop.
28
+
29
+ ```text
30
+ fabrika builder next
31
+ do the emitted work
32
+ fabrika builder gate open <phase> --view <canvas.jsx>
33
+ fabrika builder gate wait
34
+ fabrika builder advance
35
+ ```
36
+
37
+ All commands:
38
+
39
+ ```text
40
+ fabrika builder init [dir] [--profile <stack>] [--agent all|claude|codex]
41
+ fabrika builder next [--json]
42
+ fabrika builder advance [--force]
43
+ fabrika builder gate open <name> --view <file.jsx>
44
+ fabrika builder gate wait [--gate <revision>] [--timeout <ms>]
45
+ fabrika builder ensure-daemon
46
+ fabrika builder induce [--force]
47
+ fabrika builder build [--issue <ID>] [--assisted]
48
+ ```
49
+
50
+ Use `--profile cf-internal-app` for the full Buzola, Propustka, and Vozka internal-app stack. The default is `cf-lightweight`.
51
+
52
+ ## Generated application state
53
+
54
+ ```text
55
+ .fabrika/
56
+ config.json
57
+ agent-support.json
58
+ intent/
59
+ principles/
60
+ architecture/
61
+ issues/
62
+ .claude/
63
+ .agents/skills/fabrika/
64
+ .codex/agents/
65
+ tests/browser/*.test.ts
66
+ ```
67
+
68
+ The opice skeletons under `tests/browser/` are the executable acceptance contract. The generated issues form the implementation dependency graph.
69
+
70
+ ## Environment
71
+
72
+ | Variable | Purpose | Default |
73
+ | -------------------- | --------------------------------- | ----------------------------------- |
74
+ | `FABRIKA_PORT` | Local daemon port | `19410` |
75
+ | `FABRIKA_SESSION_ID` | Session override | Stable hash of the application path |
76
+ | `FABRIKA_DATA_DIR` | Persistent local state | `~/.fabrika` |
77
+ | `FABRIKA_TIMEOUT` | Gate wait timeout in milliseconds | `3600000` |
78
+ | `FABRIKA_UI_DIST` | Dashboard build override | Packaged `ui/dist` |
79
+
80
+ ## Package contents
81
+
82
+ - `cli/` owns phase derivation, task packets, issue induction, and builder commands.
83
+ - `daemon/` owns HTTP/WebSocket endpoints and persistent canvas sessions.
84
+ - `ui/` owns the local dashboard.
85
+ - `skill/` contains the agent-neutral workflow and specialist roles.
86
+ - `decisions/` and `stacks/` contain the public technology catalog.
87
+
88
+ The public `@fabrika/cli` package exposes this package under `fabrika builder …`. CLI and dashboard clients communicate with the daemon only over HTTP and WebSocket.
89
+
90
+ ## Development
91
+
92
+ From the `fabrika-platform` repository root:
93
+
94
+ ```bash
95
+ bun run --filter @fabrika/builder typecheck
96
+ bun run --filter @fabrika/builder test
97
+ bun run --filter @fabrika/builder build
98
+ ```
99
+
100
+ See [`BUILD-CONTRACT.md`](./BUILD-CONTRACT.md) for the package boundary and wire contract.
package/cli/src/bin.ts ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { cmdAdvance } from './commands/advance'
4
+ import { cmdBuild } from './commands/build'
5
+ import { cmdEnsureDaemon } from './commands/ensure-daemon'
6
+ import { cmdGate } from './commands/gate'
7
+ import { cmdInduce } from './commands/induce'
8
+ import { cmdInit } from './commands/init'
9
+ import { cmdNext } from './commands/next'
10
+
11
+ export const BUILDER_USAGE = `fabrika builder — guided application builder
12
+
13
+ Usage:
14
+ fabrika builder init [dir] [--profile <stack>] [--agent all|claude|codex]
15
+ Scaffold .fabrika/, install agent support, open dashboard
16
+ fabrika builder next [--json] Print the task packet for the current derived phase
17
+ fabrika builder advance [--force] Validate outputs and gate, then print the next phase
18
+ fabrika builder gate open <name> --view <f.jsx> Push a canvas gate and open the dashboard
19
+ fabrika builder gate wait [--gate <n>] [--timeout] Wait for submitted gate feedback
20
+ fabrika builder ensure-daemon Start the daemon if it is not running
21
+ fabrika builder induce [--force] Derive issues from opice skeleton metadata
22
+ fabrika builder build [--issue <ID>] [--assisted] Run the assisted per-issue build driver
23
+
24
+ Environment:
25
+ FABRIKA_PORT Daemon port (default 19410)
26
+ FABRIKA_SESSION_ID Session id (fallback: stable hash of repo abs path)
27
+ FABRIKA_DATA_DIR Persistent daemon/session data (default ~/.fabrika; legacy auto-detected)
28
+ FABRIKA_TIMEOUT gate wait timeout in ms (default 3600000)`
29
+
30
+ export async function runBuilderCli(argv: readonly string[]): Promise<number> {
31
+ const command = argv[0]
32
+ const rest = argv.slice(1)
33
+
34
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
35
+ console.info(BUILDER_USAGE)
36
+ return command ? 0 : 1
37
+ }
38
+
39
+ switch (command) {
40
+ case 'init':
41
+ return cmdInit(rest)
42
+ case 'next':
43
+ return cmdNext(rest)
44
+ case 'advance':
45
+ return cmdAdvance(rest)
46
+ case 'gate':
47
+ return cmdGate(rest)
48
+ case 'ensure-daemon':
49
+ return cmdEnsureDaemon(rest)
50
+ case 'induce':
51
+ return cmdInduce(rest)
52
+ case 'build':
53
+ return cmdBuild(rest)
54
+ default:
55
+ console.error(`Unknown builder command: ${command}`)
56
+ console.info(BUILDER_USAGE)
57
+ return 1
58
+ }
59
+ }
60
+
61
+ if (import.meta.main) {
62
+ runBuilderCli(process.argv.slice(2)).then((code) => process.exit(code)).catch((error: unknown) => {
63
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error))
64
+ process.exit(1)
65
+ })
66
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
2
+ import { dirname, join } from 'path'
3
+ import type { Issue } from './issues'
4
+
5
+ export type IssueStatus = 'done' | 'blocked'
6
+
7
+ export interface BuildRecord {
8
+ status: IssueStatus
9
+ reason?: string
10
+ at: string
11
+ }
12
+
13
+ export type BuildBoard = Record<string, BuildRecord>
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
17
+ }
18
+
19
+ function parseBoard(value: unknown): BuildBoard | null {
20
+ if (!isRecord(value)) return null
21
+ const board: BuildBoard = {}
22
+ for (const [id, candidate] of Object.entries(value)) {
23
+ if (!isRecord(candidate)) return null
24
+ const status = candidate['status']
25
+ const at = candidate['at']
26
+ const reason = candidate['reason']
27
+ if ((status !== 'done' && status !== 'blocked') || typeof at !== 'string') return null
28
+ if (reason !== undefined && typeof reason !== 'string') return null
29
+ board[id] = { status, at, ...(reason === undefined ? {} : { reason }) }
30
+ }
31
+ return board
32
+ }
33
+
34
+ function boardPath(fabrikaDir: string): string {
35
+ return join(fabrikaDir, 'build', 'status.json')
36
+ }
37
+
38
+ export function readBoard(fabrikaDir: string): BuildBoard {
39
+ const p = boardPath(fabrikaDir)
40
+ if (!existsSync(p)) return {}
41
+ try {
42
+ const board = parseBoard(JSON.parse(readFileSync(p, 'utf-8')))
43
+ return board ?? {}
44
+ } catch {
45
+ return {}
46
+ }
47
+ }
48
+
49
+ export function writeBoard(fabrikaDir: string, board: BuildBoard): void {
50
+ const p = boardPath(fabrikaDir)
51
+ mkdirSync(dirname(p), { recursive: true })
52
+ writeFileSync(p, JSON.stringify(board, null, 2) + '\n')
53
+ }
54
+
55
+ export function record(
56
+ fabrikaDir: string,
57
+ id: string,
58
+ status: IssueStatus,
59
+ reason: string | undefined,
60
+ at: string,
61
+ ): BuildBoard {
62
+ const board = readBoard(fabrikaDir)
63
+ board[id] = { status, ...(reason ? { reason } : {}), at }
64
+ writeBoard(fabrikaDir, board)
65
+ return board
66
+ }
67
+
68
+ export interface BuildPlan {
69
+ done: Issue[]
70
+ blocked: Issue[]
71
+ ready: Issue[]
72
+ waiting: Issue[]
73
+ next: Issue | null
74
+ complete: boolean
75
+ }
76
+
77
+ export function planBuild(issues: Issue[], board: BuildBoard): BuildPlan {
78
+ const statusOf = (id: string): IssueStatus | undefined => board[id]?.status
79
+ const doneIds = new Set(issues.filter((i) => statusOf(i.id) === 'done').map((i) => i.id))
80
+
81
+ const done: Issue[] = []
82
+ const blocked: Issue[] = []
83
+ const ready: Issue[] = []
84
+ const waiting: Issue[] = []
85
+
86
+ for (const issue of issues) {
87
+ const s = statusOf(issue.id)
88
+ if (s === 'done') {
89
+ done.push(issue)
90
+ continue
91
+ }
92
+ if (s === 'blocked') {
93
+ blocked.push(issue)
94
+ continue
95
+ }
96
+ const depsDone = issue.dependsOn.every((d) => doneIds.has(d))
97
+ if (depsDone) ready.push(issue)
98
+ else waiting.push(issue)
99
+ }
100
+
101
+ return {
102
+ done,
103
+ blocked,
104
+ ready,
105
+ waiting,
106
+ next: ready[0] ?? null,
107
+ complete: ready.length === 0 && waiting.length === 0 && done.length + blocked.length === issues.length && issues.length > 0,
108
+ }
109
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, statSync } from 'fs'
2
+ import { dirname, join, resolve } from 'path'
3
+ import { fileURLToPath } from 'url'
4
+
5
+ const HERE = dirname(fileURLToPath(import.meta.url))
6
+
7
+ export function resolveCatalogRoot(): string | null {
8
+ const candidates = [resolve(HERE, '../..')]
9
+ for (const c of candidates) {
10
+ if (isDir(join(c, 'stacks')) && isDir(join(c, 'decisions'))) return c
11
+ }
12
+ return null
13
+ }
14
+
15
+ export interface StackPointer {
16
+ profile: string
17
+ root: string
18
+ stackFile: string
19
+ stackFileExists: boolean
20
+ decisionsDir: string
21
+ }
22
+
23
+ export function resolveStackPointer(profile: string): StackPointer | null {
24
+ const root = resolveCatalogRoot()
25
+ if (!root) return null
26
+ const stackFile = join(root, 'stacks', `${profile}.md`)
27
+ return {
28
+ profile,
29
+ root,
30
+ stackFile,
31
+ stackFileExists: existsSync(stackFile) && statSync(stackFile).isFile(),
32
+ decisionsDir: join(root, 'decisions'),
33
+ }
34
+ }
35
+
36
+ function isDir(p: string): boolean {
37
+ try {
38
+ return statSync(p).isDirectory()
39
+ } catch {
40
+ return false
41
+ }
42
+ }
@@ -0,0 +1,73 @@
1
+ import { parseArgs } from 'util'
2
+ import { resolveContext } from '../context'
3
+ import { consumeFeedback, isDaemonRunning } from '../daemon-client'
4
+ import { isNonEmptyFile, matchGlob, producesSatisfied } from '../derive'
5
+ import { resolvePhaseState } from '../phase-state'
6
+ import { PHASE_COUNT, phaseById, phaseIndex, PHASES } from '../phases'
7
+
8
+ export async function cmdAdvance(argv: string[]): Promise<number> {
9
+ const { values } = parseArgs({
10
+ args: argv,
11
+ options: {
12
+ force: { type: 'boolean', default: false },
13
+ dir: { type: 'string' },
14
+ session: { type: 'string' },
15
+ },
16
+ allowPositionals: true,
17
+ })
18
+
19
+ const ctx = resolveContext({ dir: values.dir, session: values.session })
20
+ if (!ctx.initialized) {
21
+ console.error(`[fabrika] no .fabrika/ found at or above ${ctx.repoRoot}. Run \`fabrika builder init\` first.`)
22
+ return 1
23
+ }
24
+
25
+ const { state } = await resolvePhaseState(ctx.repoRoot, ctx.fabrikaDir, ctx.sessionId)
26
+ const phase = phaseById(state.current)
27
+ const problems: string[] = []
28
+
29
+ if (!producesSatisfied(ctx.repoRoot, phase)) {
30
+ for (const glob of phase.produces) {
31
+ if (glob.startsWith('<')) {
32
+ problems.push(`missing produce: ${glob} (manual/terminal — cannot auto-validate)`)
33
+ continue
34
+ }
35
+ const matches = matchGlob(ctx.repoRoot, glob).filter(isNonEmptyFile)
36
+ if (matches.length === 0) problems.push(`missing produce: ${glob}`)
37
+ }
38
+ }
39
+
40
+ if (phase.gate && !values.force) {
41
+ const daemonUp = await isDaemonRunning()
42
+ if (!daemonUp) {
43
+ problems.push(
44
+ `gate "${phase.id}" cannot be validated offline (daemon down). `
45
+ + `Open the gate + submit feedback, or pass --force.`,
46
+ )
47
+ } else {
48
+ const feedback = await consumeFeedback(ctx.sessionId)
49
+ if (feedback === null) {
50
+ problems.push(
51
+ `gate "${phase.id}" has no submitted feedback to consume. `
52
+ + `Run: fabrika builder gate open ${phase.id} --view ${phase.gateView ?? phase.id + '.jsx'} && fabrika builder gate wait`,
53
+ )
54
+ }
55
+ }
56
+ }
57
+
58
+ if (problems.length > 0) {
59
+ console.error(`[fabrika] cannot advance from phase ${phase.number}/${PHASE_COUNT} (${phase.id}):`)
60
+ for (const p of problems) console.error(` - ${p}`)
61
+ return 1
62
+ }
63
+
64
+ const idx = phaseIndex(phase.id)
65
+ const next = idx + 1 < PHASES.length ? PHASES[idx + 1] : null
66
+ if (next) {
67
+ console.log(`OK: phase ${phase.number}/${PHASE_COUNT} (${phase.id}) satisfied.`)
68
+ console.log(`Next -> phase ${next.number}/${PHASE_COUNT} - ${next.title}. Run \`fabrika builder next\`.`)
69
+ } else {
70
+ console.log(`OK: all ${PHASE_COUNT} phases satisfied. Run \`fabrika builder build\`.`)
71
+ }
72
+ return 0
73
+ }
@@ -0,0 +1,215 @@
1
+ import { join } from 'path'
2
+ import { parseArgs } from 'util'
3
+ import { type BuildPlan, planBuild, readBoard, record } from '../build-status'
4
+ import { resolveStackPointer } from '../catalog'
5
+ import { resolveContext } from '../context'
6
+ import { postBuildEvent } from '../daemon-client'
7
+ import { type Issue, readIssues } from '../issues'
8
+
9
+ export async function cmdBuild(argv: string[]): Promise<number> {
10
+ const { values, positionals } = parseArgs({
11
+ args: argv,
12
+ options: {
13
+ reason: { type: 'string' },
14
+ issue: { type: 'string' },
15
+ dir: { type: 'string' },
16
+ session: { type: 'string' },
17
+ assisted: { type: 'boolean', default: false },
18
+ },
19
+ allowPositionals: true,
20
+ })
21
+
22
+ const sub = positionals[0] ?? 'next'
23
+ const idArg = positionals[1] ?? values.issue
24
+
25
+ const ctx = resolveContext({ dir: values.dir, session: values.session })
26
+ if (!ctx.initialized) {
27
+ console.error(`[fabrika] no .fabrika/ found at or above ${ctx.repoRoot}. Run \`fabrika builder init\` first.`)
28
+ return 1
29
+ }
30
+
31
+ const issues = readIssues(ctx.fabrikaDir)
32
+ if (issues.length === 0) {
33
+ console.error(
34
+ `[fabrika] no issues in ${ctx.fabrikaDir}/issues/. Run \`fabrika builder induce\` (phase 7) first.`,
35
+ )
36
+ return 1
37
+ }
38
+ const byId = (id: string) => issues.find((i) => i.id === id) ?? null
39
+
40
+ switch (sub) {
41
+ case 'status':
42
+ printBoard(planBuild(issues, readBoard(ctx.fabrikaDir)))
43
+ return 0
44
+
45
+ case 'done': {
46
+ if (!idArg || !byId(idArg)) {
47
+ console.error(`[fabrika] build done <ISSUE-ID> — unknown or missing issue id.`)
48
+ return 1
49
+ }
50
+ record(ctx.fabrikaDir, idArg, 'done', undefined, new Date().toISOString())
51
+ await postBuildEvent({ issue: idArg, status: 'passed', message: 'issue green (bun test passed)' })
52
+ console.log(`[fabrika] ✓ ${idArg} marked done.`)
53
+ return await reportProgress(issues, ctx.fabrikaDir)
54
+ }
55
+
56
+ case 'block': {
57
+ if (!idArg || !byId(idArg)) {
58
+ console.error(`[fabrika] build block <ISSUE-ID> --reason "…" — unknown or missing issue id.`)
59
+ return 1
60
+ }
61
+ const reason = values.reason ?? 'blocked (no reason given)'
62
+ record(ctx.fabrikaDir, idArg, 'blocked', reason, new Date().toISOString())
63
+ await postBuildEvent({ issue: idArg, status: 'blocked', message: reason })
64
+ console.log(`[fabrika] ⚠ ${idArg} blocked: ${reason}`)
65
+ console.log(`[fabrika] continuing to independent issues; collect blocked at final review.`)
66
+ return await reportProgress(issues, ctx.fabrikaDir)
67
+ }
68
+
69
+ case 'next':
70
+ default: {
71
+ if (values.issue && byId(values.issue)) {
72
+ await postBuildEvent({ issue: values.issue, status: 'running', message: 'building (re-selected)' })
73
+ process.stdout.write('\n' + renderBuildPacket(byId(values.issue)!, ctx.repoRoot, ctx.profile))
74
+ return 0
75
+ }
76
+ return await emitBrief(issues, ctx.fabrikaDir, ctx.repoRoot, ctx.profile)
77
+ }
78
+ }
79
+ }
80
+
81
+ async function emitBrief(
82
+ issues: Issue[],
83
+ fabrikaDir: string,
84
+ repoRoot: string,
85
+ profile: string,
86
+ ): Promise<number> {
87
+ const plan = planBuild(issues, readBoard(fabrikaDir))
88
+ printSummary(plan)
89
+
90
+ if (plan.complete) {
91
+ return await reportComplete(plan, issues.length)
92
+ }
93
+
94
+ await postBuildEvent({
95
+ status: 'info',
96
+ message: `build: ${plan.done.length}/${issues.length} done, ${plan.ready.length} ready`,
97
+ })
98
+ process.stdout.write('\n' + renderOrchestrationBrief(plan, repoRoot, profile))
99
+ return 0
100
+ }
101
+
102
+ async function reportProgress(issues: Issue[], fabrikaDir: string): Promise<number> {
103
+ const plan = planBuild(issues, readBoard(fabrikaDir))
104
+ if (plan.complete) return await reportComplete(plan, issues.length)
105
+ printBoard(plan)
106
+ if (!plan.next && plan.waiting.length) {
107
+ console.error(`\n[fabrika] nothing buildable: ${plan.waiting.length} waiting on blocked deps (see final review).`)
108
+ }
109
+ return 0
110
+ }
111
+
112
+ async function reportComplete(plan: BuildPlan, total: number): Promise<number> {
113
+ await postBuildEvent({ status: 'info', message: `build complete: ${plan.done.length}/${total} green` })
114
+ console.log(
115
+ `\n[fabrika] ✓ build complete — ${plan.done.length}/${total} issues green`
116
+ + (plan.blocked.length ? `, ${plan.blocked.length} blocked (see final review).` : '.'),
117
+ )
118
+ if (plan.blocked.length) {
119
+ console.log(`\nBlocked (final review):`)
120
+ for (const b of plan.blocked) console.log(` ⚠ ${b.id} ${b.goal}`)
121
+ }
122
+ return 0
123
+ }
124
+
125
+ function printSummary(plan: BuildPlan): void {
126
+ const total = plan.done.length + plan.blocked.length + plan.ready.length + plan.waiting.length
127
+ console.log(
128
+ `build: ${plan.done.length}/${total} done · ${plan.ready.length} ready · `
129
+ + `${plan.waiting.length} waiting · ${plan.blocked.length} blocked`,
130
+ )
131
+ }
132
+
133
+ function printBoard(plan: BuildPlan): void {
134
+ printSummary(plan)
135
+ const line = (label: string, xs: Issue[]) => xs.length && console.log(`\n${label}:\n` + xs.map((i) => ` ${i.id} ${i.goal}`).join('\n'))
136
+ line('✓ done', plan.done)
137
+ line('→ ready', plan.ready)
138
+ line('… waiting', plan.waiting)
139
+ line('⚠ blocked', plan.blocked)
140
+ }
141
+
142
+ function stackBriefLines(profile: string): string[] {
143
+ const p = resolveStackPointer(profile)
144
+ if (!p) return []
145
+ if (!p.stackFileExists) {
146
+ return [
147
+ `Stack: ${profile} — preset NOT found; pick one of ${join(p.root, 'stacks')}/*.md (decisions under ${p.decisionsDir}/)`,
148
+ ]
149
+ }
150
+ return [
151
+ `Stack: ${profile} -> ${p.stackFile}`,
152
+ ` resolve its \`decisions:\` ids under ${p.decisionsDir}/ and read the live \`sources\` they pin`,
153
+ ]
154
+ }
155
+
156
+ function renderOrchestrationBrief(plan: BuildPlan, repoRoot: string, profile: string): string {
157
+ const marker = new Map<string, string>()
158
+ for (const i of plan.done) marker.set(i.id, '✓')
159
+ for (const i of plan.blocked) marker.set(i.id, '⚠')
160
+ for (const i of plan.ready) marker.set(i.id, '→')
161
+ for (const i of plan.waiting) marker.set(i.id, '…')
162
+ const all = [...plan.done, ...plan.ready, ...plan.waiting, ...plan.blocked].sort((a, b) => a.id.localeCompare(b.id))
163
+ const doneIds = new Set(plan.done.map((i) => i.id))
164
+
165
+ const dag = all.map((i) => {
166
+ const deps = i.dependsOn.length ? ` deps: ${i.dependsOn.join(', ')}` : ''
167
+ const note = marker.get(i.id) === '…'
168
+ ? ` (waiting on ${i.dependsOn.filter((d) => !doneIds.has(d)).join(', ')})`
169
+ : ''
170
+ return ` ${marker.get(i.id) ?? '?'} ${i.id} ${i.goal}${deps}${note}`
171
+ })
172
+
173
+ return [
174
+ `## Build — ${all.length} issues`,
175
+ `Apply the bundled **agents/build-orchestrator.md** playbook in the MAIN session.`,
176
+ `Do not spawn a nested orchestrator. SUPERVISE and fan out one direct`,
177
+ `implementer subagent per issue (use the fabrika-implementer role; worktree-isolated`,
178
+ `where they run in parallel). Do NOT hand-code the whole DAG yourself.`,
179
+ ``,
180
+ `Repo: ${repoRoot}`,
181
+ ``,
182
+ `Issue DAG (foundation first; build independents in parallel):`,
183
+ ...dag,
184
+ ``,
185
+ `Per issue: implement → opice-author (selectors vs the running app) → \`bun test\` green.`,
186
+ `Acceptance: the issue's \`satisfies\` scenarios + FROZEN invariants must pass.`,
187
+ ` Never weaken an invariant statement to go green — adversarially verify it.`,
188
+ ...stackBriefLines(profile),
189
+ `Scaffold: NO templates — read the stack preset + its decisions + the live sources`,
190
+ ` they point to, and scaffold from current APIs (your memory is likely stale).`,
191
+ `Record: fabrika builder build done <ID> | fabrika builder build block <ID> --reason "…"`,
192
+ ` (both write status.json + stream a BuildEvent to the dashboard)`,
193
+ `Detail: fabrika builder build --issue <ID> (re-emit one issue's full packet)`,
194
+ ].join('\n') + '\n'
195
+ }
196
+
197
+ function renderBuildPacket(issue: Issue, repoRoot: string, profile: string): string {
198
+ const lines: string[] = []
199
+ lines.push(`## Build issue ${issue.id}`)
200
+ lines.push(`Goal: ${issue.goal || '(no goal recorded)'}`)
201
+ lines.push(`Satisfies: ${issue.satisfies.length ? issue.satisfies.join(', ') : '(none)'}`)
202
+ lines.push(`Depends: ${issue.dependsOn.length ? issue.dependsOn.join(', ') : '(none)'}`)
203
+ lines.push(`Touches: ${issue.touches.length ? issue.touches.join(', ') : '(none)'}`)
204
+ const sp = resolveStackPointer(profile)
205
+ if (sp?.stackFileExists) {
206
+ lines.push(`Stack: ${profile} -> ${sp.stackFile} (decisions under ${sp.decisionsDir}/)`)
207
+ }
208
+ lines.push(`Pipeline: implement -> opice-author (selectors vs running app) -> bun test (steps + invariants)`)
209
+ lines.push(`Done when: green \`bun test\` for the satisfied scenarios/invariants.`)
210
+ lines.push(`Repo: ${repoRoot}`)
211
+ lines.push(``)
212
+ lines.push(`After green: fabrika builder build done ${issue.id} (records it + emits the next packet)`)
213
+ lines.push(`If stuck: fabrika builder build block ${issue.id} --reason "…" (escalate, continue)`)
214
+ return lines.join('\n') + '\n'
215
+ }